Skip to content

fix(cases): bound path probes for linked workspaces and session creation, so an unreachable mount cannot freeze the server - #516

Open
aakhter wants to merge 2 commits into
Ark0N:masterfrom
aakhter:pr/bounded-path-probe
Open

aakhter wants to merge 2 commits into
Ark0N:masterfrom
aakhter:pr/bounded-path-probe

Conversation

@aakhter

@aakhter aakhter commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

A linked case can live on a network mount. When that mount goes away, a hard mount makes stat() wait indefinitely. The synchronous probes in the case routes, the workspace hook and statusLine helpers, and session creation (POST /api/sessions workingDir, POST /api/quick-start case folder) then froze the whole server.

The probe. The fix is a bounded, tri-state path probe (src/utils/bounded-path-probe.ts): present, absent (ENOENT/ENOTDIR only) or unknown (no answer within the timeout, another error, or refused).

  • It shares one in-flight stat per path.
  • A stat that times out is remembered until it settles. While it is stalled, paths on the same mount are answered "unknown" without a new stat, so a dead mount costs one threadpool worker.
  • Unrelated paths keep probing, and a process-wide cap on stalled stats is only a backstop.
  • Each stall, and the cap engaging, is logged once.

Callers never treat "unknown" as "absent".

  • GET /api/cases/:name keeps NOT_FOUND for definite absence. An unreachable linked case comes back with its registered path and unreachable: true, and the Run button creates a case only on NOT_FOUND.
  • Hooks install normally in any workspace that is not on the dead mount.
  • Session creation answers OPERATION_FAILED for a folder that did not answer, and never scaffolds over it.
  • Writers use an ENOENT-aware async lstat, and the clone flow's repo-settings warning keeps its synchronous check.

Tuning. The timeout and the stall cap live in src/config/path-probe.ts, overridable with CODEMAN_PATH_PROBE_TIMEOUT_MS (default 1500) and CODEMAN_PATH_PROBE_MAX_STALLED (default 3). A slow but healthy mount, such as an sshfs that needs a couple of seconds on first touch, can raise the timeout.

Tests

  • Unit tests for the probe: timeout, shared probe, mount scoping, the backstop cap, recovery, and a healthy path probing true while unrelated paths are stalled.
  • Route tests for the case list, GET /api/cases/:name on the timeout path, fix-plan, clone, and both session-create routes. They simulate a hard mount as a frozen synchronous call plus a never-settling async stat.
  • Caller tests for hooks and statusLine under the cap and on the dead mount.
  • Launcher tests that Run creates a case only on NOT_FOUND.

@Ark0N

Ark0N commented Oct 1, 2026

Copy link
Copy Markdown
Owner

DRAFT_COMMENT

@aakhter

aakhter commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

Hi @Ark0N, it looks like your comment here came through as the placeholder text "DRAFT_COMMENT", so I think the actual review didn't post. Happy to pick it up whenever you get a chance to re-send it.

@Ark0N

Ark0N commented Oct 4, 2026

Copy link
Copy Markdown
Owner

Thanks for this, @aakhter, and sorry for the "DRAFT_COMMENT" placeholder that went out here on 10-01. That was a glitch in my review tooling, and this is the review that should have been posted.

This PR stops a linked case on an unreachable network mount from freezing the server. boundedPathExists() stats asynchronously, gives up after 1.5 s, shares one in-flight probe per path, remembers a stalled path until its stat settles, and stops starting new probes while two stalled ones hold threadpool workers. The stall mechanics are carefully done: no unhandled rejections, timers cleared, and the route test proves GET /api/cases keeps answering (it fails on master). That part is worth keeping as is. Typecheck, lint, format, the touched-area tests and CI all pass here, and the branch still merges cleanly onto 1.34.0.

What needs another round is how callers read a non-answer. Today "did not answer in 1.5 s" and "refused by the cap" both come back as false, i.e. "absent", and in two places that causes something worse than the freeze. Both were confirmed with throwaway tests on your own route harness.

Must fix

  1. A slow or stalled case now 404s, and the Run button answers a 404 by creating a stray local case (src/web/routes/case-routes.ts:1641, with src/web/public/session-ui.js:1869 and :2077). GET /api/cases/:name returns NOT_FOUND whenever boundedPathExists(casePath) is false. runClaude() and runShell() call that endpoint first and, when the answer has no data.path, POST /api/cases to create it. For a linked case that POST succeeds: it scaffolds ~/codeman-cases/<name> with a template CLAUDE.md and hooks, and the session starts there instead of in the user's project. Once the mount recovers, the stray directory shadows the linked case in GET /api/cases (local entries are listed first, and the linked loop skips names it has already seen), while GET /api/cases/:name resolves the linked path, so the two endpoints disagree from then on. Under the cap from item 2, a healthy local case 404s too, the follow-up POST fails with ALREADY_EXISTS, and Run shows "Case already exists".

    Please make the probe distinguish "absent" from "unknown": a tri-state ('present' | 'absent' | 'unknown'), or a boolean plus a separate timedOut. Keep NOT_FOUND for a definite absence only. For "unknown", either return the registered path (the linked registry already knows it) or return an error that is not NOT_FOUND, and make runClaude/runShell create a case only on errorCode === 'NOT_FOUND'. Please add a route test for the timeout path of GET /api/cases/:name.

  2. The stalled-probe cap is process-wide, so two stalled paths make every path read as absent (src/utils/bounded-path-probe.ts:58). Protecting the threadpool is the right concern, but the refusal also answers false for paths that have nothing to do with the dead mount. On a hard mount the stalled stats don't settle until the mount comes back, which can take hours. One dead NAS reaches two stalled paths easily: two linked cases on it, or one linked case plus its CLAUDE.md. While that lasts:

    • applyWorkspaceHooks() (src/hooks-config.ts:841) returns early for every workspace, so every new claude session, cron run and boot-recovered session runs without Codeman's hooks. That means no permission-prompt alert, no Approvals Inbox item, no push, and no stop/idle_prompt for respawn or the wait endpoints, and nothing is logged. A throwaway test confirmed it: a healthy temp workspace got no settings.local.json while two unrelated paths were stalled, and got one after they were released.
    • every case's hasClaudeMd reads false, and GET /api/cases/:name 404s for every case (feeding item 1).
    • resolveStatusLineCliCommand() (:1120) misses the user's own statusLine, so the plan-usage exporter is injected over it, against that function's own "never override a real one".
    • the clone flow's repo-settings warning disappears (item 3), and GET /api/cases/:name/fix-plan reports no plan.

    Your unit test pins this on purpose (/healthy/three reads false while two other paths are stalled), so the gap is the effect on the callers rather than the helper itself. Either of these works, your choice: scope the refusal to paths near a stalled one (same linked-case root or same leading mount segments) and probe everything else normally, or return "unknown" when refusing and let each caller decide (hook installation should go ahead for a workspace that is not itself stalled, and a listing can mark the case unreachable). Either way, please console.warn once when a path first stalls and once when the cap engages, so "my case vanished" and "hooks stopped firing" leave a trace. And please add a test that a healthy path still probes true while two unrelated paths are stalled.

Should fix

  1. Keep the clone flow's security warning off the bounded probe (src/web/routes/case-routes.ts:166-171, repoShipsClaudeSettings). It runs on a directory that was just cloned into the local case space, right after a synchronous lstatSync on the same tree, so the bound protects nothing there. What it adds is a way to lose the "repo-supplied hooks run on this machine" warning while the cap is engaged, and that warning is one of the clone feature's guarantees. The previous synchronous check (or an ENOENT-aware lstat like your pathExistsForWrite) is right here.

  2. stripCaseEnvKeys is a writer but uses the read-side probe (src/hooks-config.ts:579). It runs inside withSafeSettingsWrite, which has already lstated the same file without a bound, so the bounded probe buys nothing. A false negative, though, leaves behind the stale entry this function exists to remove, and it keeps shadowing the fresh per-session override. Please use pathExistsForWrite() here, as your own docblock says writers should, or drop the check and let the existing readFile catch handle ENOENT.

  3. Session creation still stats the linked path synchronously (src/web/routes/session-routes.ts:3661, existsSync(resolvedCasePath) in POST /api/quick-start, and :951, statSync(workingDir) in POST /api/sessions). The description says the case list and session creation were blocking. The list is fixed, but starting a session in the dead case through quick-start (the TUI, the codeman skill, any API client) or POST /api/sessions still blocks the event loop. Either bound these two as well (the same one-line swap, and POST /api/sessions already maps a non-directory to INVALID_INPUT), or reword the title and description so the changelog doesn't promise more than ships.

Small ones

  1. On your barrel question: yes, please export boundedPathExists from src/utils/index.ts and import it from ./utils in case-routes.ts and hooks-config.ts. That's the import convention in CLAUDE.md.
  2. PROBE_TIMEOUT_MS and MAX_STALLED_PROBES are operational limits, and limits live in src/config/, ideally env-overridable like the others. A slow but healthy sshfs that needs 2 s now drops out of the case list on first touch, so someone may well want to tune it. It's worth a sentence in the description too.

Once items 1 and 2 are in with their tests, this gets another review, and 3 and 4 are one-line swaps that can ride the same push. Thanks again for chasing this one down. The freeze is real, and the hard part of the fix is already done well.

johoja12 and others added 2 commits October 4, 2026 20:08
…t cannot freeze the server

A linked case can live on a network mount. When that mount goes away, a
hard mount makes stat() wait indefinitely, and the existsSync() probes in
the case routes and the workspace hook/statusline helpers ran on the event
loop, so a single GET /api/cases (or a session create in that workspace)
froze the whole web server until the mount came back.

Add boundedPathExists() (src/utils/bounded-path-probe.ts): an async stat
that answers "absent" after 1.5 s, shares one in-flight probe per path,
remembers a timed-out path until its stat finally settles, and refuses to
start new probes while two stalled ones still hold libuv threadpool
workers. Route the read-side probes in case-routes.ts and hooks-config.ts
through it. The settings writers in hooks-config.ts use an async lstat
that treats only ENOENT as missing, so an unreachable workspace is never
mistaken for an empty one and has its settings recreated.
…all cap

The bounded path probe answered "absent" both when a path did not exist and
when it simply did not answer, so a stalled linked case 404'd and the Run
button scaffolded a stray local case over it, and two stalled paths anywhere
made every unrelated path read as absent (hooks skipped, statusLine
overridden, the clone warning lost).

- probePath()/probePathKind() are tri-state: present (or directory/file),
  absent (ENOENT/ENOTDIR only) and unknown (timeout, other errors, refusal).
  boundedPathExists() stays as the display-only boolean.
- A stalled path takes only its own mount out of probing (deepest mount
  point from /proc/self/mounts, never /; just the path itself when there is
  no mount table). Unrelated paths keep probing. The process-wide cap is a
  backstop that answers unknown, and a single-path user request can probe
  past it ({ pastCap: true }), still bounded and still recorded as stalled.
  One console.warn when a path first stalls and one when the cap engages.
- GET /api/cases/:name keeps NOT_FOUND for definite absence only. An
  unreachable linked case answers with its registered path and
  unreachable: true; a local one answers OPERATION_FAILED. runClaude and
  runShell create a case only on errorCode NOT_FOUND. The case list keeps an
  unreachable linked case, marked unreachable, instead of dropping it, and
  fix-plan reports an unreadable plan as an error, not "no plan".
- applyWorkspaceHooks and the statusLine helpers skip only a workspace that
  is absent or on the stalled mount; a capacity refusal no longer stops
  hooks being installed elsewhere, and an unreadable settings file never
  lets the exporter override a user's own statusLine.
- The clone flow's repo-settings warning is back on its synchronous check,
  and stripCaseEnvKeys uses pathExistsForWrite.
- POST /api/sessions (workingDir) and POST /api/quick-start (case folder)
  probe with the bounded probe instead of statSync/existsSync. Missing and
  non-directory keep INVALID_INPUT; unknown is OPERATION_FAILED, and
  quick-start never scaffolds over a folder that did not answer.
- PATH_PROBE_TIMEOUT_MS and MAX_STALLED_PATH_PROBES move to
  src/config/path-probe.ts, overridable via CODEMAN_PATH_PROBE_TIMEOUT_MS
  (default 1500) and CODEMAN_PATH_PROBE_MAX_STALLED (default 3), and are
  documented in the Settings Reference.
- The probe is exported from the utils barrel and imported from there.
@aakhter aakhter changed the title fix(cases): bound linked-workspace path probes so an unreachable mount cannot freeze the server fix(cases): bound path probes for linked workspaces and session creation, so an unreachable mount cannot freeze the server Oct 5, 2026
@aakhter
aakhter force-pushed the pr/bounded-path-probe branch from cf9748e to d1bfbb4 Compare October 5, 2026 00:33
@aakhter

aakhter commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review, and no worries at all about the DRAFT_COMMENT placeholder; this was well worth the wait. The throwaway tests on my own route harness made both must-fix items very concrete. I've rebased onto 1.34.0 and pushed one new commit on top of the original (d1bfbb4):

  1. Absent vs unknown. The probe is now tri-state (present, absent, unknown), and only ENOENT/ENOTDIR count as absent.
    • GET /api/cases/:name keeps NOT_FOUND for a definite absence. An unreachable linked case answers with its registered path plus unreachable: true, and an unreachable local case answers OPERATION_FAILED.
    • runClaude and runShell now create a case only on errorCode === 'NOT_FOUND'.
    • The case list also keeps an unreachable linked case, marked unreachable, so it no longer looks deleted, and fix-plan reports unknown as an error rather than "no plan".
    • Tests cover the timeout path of the route, an EIO case, and both launchers.
  2. Stall cap. I went with both of your options.
    • A stalled path now takes only its own mount out of probing (the deepest mount point from /proc/self/mounts, never /), and the global cap is a backstop that answers "unknown".
    • A single-path user request (opening a case, starting a session) can probe past the cap, still bounded.
    • Hooks install in any workspace that is not itself on the dead mount, and the statusLine exporter never overrides a settings file it could not read.
    • There is one console.warn when a path first stalls and one when the cap engages.
    • The /healthy/three test is replaced by one showing a healthy path probing true with two unrelated paths stalled. There are also caller-level tests for hooks, statusLine and GET with the cap engaged.
  3. Clone warning. repoShipsClaudeSettings is back on its synchronous check, with a test that the warning survives the cap.
  4. stripCaseEnvKeys uses pathExistsForWrite, with a test.
  5. Session creation. I bounded both stats rather than rewording.
    • Missing and non-directory keep INVALID_INPUT. A folder that does not answer gets OPERATION_FAILED, and quick-start never scaffolds over it.
    • Route tests simulate the hard mount as a frozen synchronous call and a never-settling async stat.
    • One small behaviour change: an unreadable (EACCES) workingDir now reports "not responding or not readable" instead of "does not exist".
  6. Barrel. The probe is exported from src/utils/index.ts and imported from ./utils.
  7. Config. The limits moved to src/config/path-probe.ts and can be overridden with CODEMAN_PATH_PROBE_TIMEOUT_MS and CODEMAN_PATH_PROBE_MAX_STALLED. Both are in the Settings Reference and the description. I raised the cap default to 3, since with mount scoping it now only engages after three unrelated places stop answering. Happy to put it back to 2.

The title and description now cover session creation too. Every new test failed before its fix, and reverting each of the fixes for items 1, 2, 3 and 5 makes its tests fail. Typecheck, lint, format, the frontend-syntax and browser-exclude checks, and the test files touching these modules all pass.

@Ark0N

Ark0N commented Oct 5, 2026

Copy link
Copy Markdown
Owner

Thanks for the quick and careful turnaround, @aakhter. This PR makes every probe of a linked case folder or session workingDir bounded and tri-state, so an unreachable network mount can no longer freeze the server. All seven items from the last round are in, each with a test, and typecheck, lint, format, the frontend checks and the full test suite pass here.

Three edge cases in the new mount-scoping and pastCap code need one more small round. I reproduced each one with a throwaway test.

1. Under the stall cap, applyWorkspaceHooks recreates a deleted workspace (src/hooks-config.ts:855). absentOrUnreachable() returns false for an unknown that is not near a stalled path. A probe refused by the cap is exactly that, even when the path is gone. ensureCodemanHooks then runs mkdir -p on .claude, which is the resurrection the old existsSync guard prevented (see the docblock at :840 and docs/architecture-invariants.md:322). With three unrelated paths stalled, applyWorkspaceHooks('<tmp>/deleted-repo', true) created <tmp>/deleted-repo/.claude/settings.local.json. When the probe answered unknown, please check existence with your pathExistsForWrite(workspace) before installing, and add a test that engages the cap and checks a deleted workspace stays deleted.

2. pastCap has no ceiling (src/utils/bounded-path-probe.ts:133). Each pastCap probe of a new path that hangs adds one stalled stat, so the count can reach libuv's default pool of 4. Then every async fs, dns.lookup and crypto call in the process waits on the dead mount. Your own "lets a pastCap probe through the cap" test ends with 4 hung stats. Mount scoping keeps this rare on Linux, but two common setups still get there:

  • macOS has no /proc/self/mounts, so each path is its own scope, and four linked cases on one NAS are enough.
  • On Linux, the same happens for linked paths reached through a symlink whose string prefix is on /.

Please give pastCap a hard ceiling that keeps one worker free (for example stalled.size < (Number(process.env.UV_THREADPOOL_SIZE) || 4) - 1, or put the bulk default back to 2 and let pastCap go to 3), with a test.

3. The stall scope cannot see through symlinks (src/utils/bounded-path-probe.ts:98). stallScope() takes the deepest mount whose path is a string prefix of the stalled path, and linked paths are stored as typed. Take /home as its own local mount and a linked case at ~/nas/project (~/nas -> /mnt/nas): a stall there records /home as the scope. Every path under it, ~/codeman-cases/* included, then reads unknown even with pastCap, so session creation, Run and hook installation fail across the home directory until the NAS comes back. /proc/self/mounts has the fs type in the third field. Please widen to the mount only for network and FUSE types (nfs, nfs4, cifs, smb3, smbfs, 9p, ceph, glusterfs, afs, lustre, davfs, fuse.*) and narrow to the stalled path otherwise, with a test using a local /home row.

Two small ones that can ride along:

  • src/config/path-probe.ts:31 still says a stall takes out its "same parent directory". It scopes by mount point now.
  • src/web/routes/case-routes.ts:1668: GET /api/cases/:name probes the folder with pastCap but its CLAUDE.md without it, so a healthy case reads hasClaudeMd: false under the cap. Using probePath(..., { pastCap: true }) there keeps the two consistent.

Once 1 to 3 are in with their tests, this is ready to merge. Thanks again: the freeze is real, and the probe itself (timers, in-flight sharing, no stray rejections) is in good shape.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants